[Rust] N-APIアドオンを作成してTypeScriptコードを変換する [Bun 1.2]
Introduction
先日js/tsツールキットであるBunの1.2がリリースされました。
S3 API が組み込まれてデフォルトでS3アクセス可能になったり
Bun.sqlでPostgresqlにアクセスできたり(MySQLは近日対応予定)、
テキストベースのロックファイルが導入されたり、HTTP/2やExpressが高速化されたりと
盛りだくさんの内容です。
その中の1つ、Plugin APIの新しいフック、「onBeforeParse」が使えるようになりました。
これはRustやZig などでN-API Addonとして実装します。
これを使うとBunがtsをパースする前にコードをフックし、
中身を変更したりとかができます。
今回はRustでBunのpluginをつくってみます。
Environment
- MacBook Pro (14-inch, M3, 2023)
- OS : MacOS 14.5
- Bun : 1.2
- Rust : 1.83.0
Setup
まずは必要なソフトウェアのインストールです。
Bunをインストールしましょう。
% curl -fsSL https://bun.sh/install | bash # for macOS, Linux, and WSL
# すでにインストールしている場合はupgrade
% bun upgrade
Rustでpluginのプロジェクト生成&ビルドするため、napiのcliとかyarnが必要なので
これらもインストールします。
% bun add -g @napi-rs/cli
% bun add -g yarn
適当なディレクトリをつくってnapiコマンドでプロジェクトの雛形を作成します。
Package name、Dir nameは適当に指定して、github actionsは使いません。
% cd my-bun-plugin
% napi new
? Package name: (The name filed in your package.json) napi-example
? Dir name: napi-example
? Choose targets you want to support x86_64-apple-darwin, x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu
? Enable github actions? No
・・・
➤ YN0000: · Done in 2s 813ms
そしてbun-native-pluginをcargo addします。
% cargo add bun-native-plugin
Updating crates.io index
Adding bun-native-plugin v0.1.2 to dependencies
Features:
+ napi
Updating crates.io index
Locking 34 packages to latest compatible versions
ここにある、コード中のfooをbarに変えるサンプルをlib.rsに記述。
use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, anyhow, BunLoader};
use napi_derive::napi;
/// Define the plugin and its name
define_bun_plugin!("replace-foo-with-bar");
/// Here we'll implement `onBeforeParse` with code that replaces all occurrences of
/// `foo` with `bar`.
///
/// We use the #[bun] macro to generate some of the boilerplate code.
///
/// The argument of the function (`handle: &mut OnBeforeParse`) tells
/// the macro that this function implements the `onBeforeParse` hook.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
// Fetch the input source code.
let input_source_code = handle.input_source_code()?;
// Get the Loader for the file
let loader = handle.output_loader();
let output_source_code = input_source_code.replace("foo", "bar");
handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);
Ok(())
}
次にnapiモジュールをコンパイルします。
napi-rsがビルドスクリプトまで用意してくれてるので、それを使います。
% bun run build
$ napi build --platform --release
・・・・
warning: `napi-example` (lib) generated 3 warnings (run `cargo fix --lib -p napi-example` to apply 1 suggestion)
Finished `release` profile [optimized] target(s) in 9.56s
ビルドが完了すると、「napi-example.darwin-arm64.node」ができているはずです。
pluginができたので、次は対象となる適当なtsファイルを作成します。
とりあえずtest.tsという名前で以下のファイルを記述。
// test.ts
console.log("foo is here");
const message = "foo foo foo";
bunで実行してみる。
% bun run test.ts
foo is here
そしてさきほどのpluginをつかってビルドするためのスクリプト、
build.jsを作成します。
ビルド対象のファイル、さきほど作成したプラグインファイルを指定します。
//build.js
const path = require("path");
const result = await Bun.build({
entrypoints: ["./test.ts"],
outdir: "./dist",
plugins: [
{
name: "replace-foo-with-bar",
setup(build) {
// プラグインファイルへの絶対パス
const napiModule = require(path.resolve("./napi-example.darwin-arm64.node"));
build.onBeforeParse(
{
filter: /\.tsx?$/, // .ts または .tsx ファイルに適用
namespace: "file"
},
{
napiModule,
symbol: "replace_foo_with_bar"
}
);
build.onStart(() => {
console.log("Build started!");
});
}
}
],
// ソースマップを生成して確認しやすくする
sourcemap: "external",
});
console.log("Build completed!");
console.log("Outputs:", result.outputs.map(o => o.path));
bunでビルドスクリプトを実行しましょう。
% bun run build.js
Build started!
Build completed!
Outputs: [ "/・・・/dist/test.js", "/・・・/dist/test.js.map" ]
distにjsファイルが生成されてます。
pluginが適用され、fooがbarになってます。
% bun run dist/test.js
bar is here
ちなみに、tsファイルで↓みたいにするとスクリプト実行時にエラーになりますが、
let x:string = "foo";
pluginのBunLoader部分を↓に変更したら動きました。
BunLoader::BUN_LOADER_JSX
↓
BunLoader::BUN_LOADER_TSX
コードのパースをする
一応コードの変更ができたのですが、pluginでは単純な文字列を取得して置換してるだけです。
他になにかできないかとおもってたら、swc系のcrateが良さそうなのでためしてみました。
これらのcrateはSwc (Speedy Web Compiler) プロジェクトの一部で、js/tsのパースや変換などで使うコンポーネントとのことです。
- swc_common - 基本的なユーティリティと共通機能を提供
- swc_ecma_parser - JavaScriptとTypeScriptのパーサー
- swc_ecma_ast - ECMAScript/JavaScriptのAST定義
これらのcrateを追加してpluginを少し書き換えます。
% cargo add swc_common swc_ecma_parser swc_ecma_ast
パース処理を途中でいれてみました。
※出力はさっきと同じです
//lib.rs
use bun_native_plugin::{define_bun_plugin, bun, Result, BunLoader};
use napi_derive::napi;
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
use swc_common::{SourceMap, FileName};
use std::sync::Arc;
define_bun_plugin!("replace-foo-with-bar");
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
// 入力ソースコードを取得
let input_source_code = handle.input_source_code()?;
// パース処理の準備
let source_map = SourceMap::default();
let source_file = source_map.new_source_file(
FileName::Anon.into(),
input_source_code.to_string(),
);
// setting lexer&parser
let lexer = Lexer::new(
Syntax::Es(Default::default()),
Default::default(),
StringInput::from(&*source_file),
None,
);
let mut parser = Parser::new_from(lexer);
match parser.parse_module() {
Ok(module) => {
println!("=== Parsed Module Structure ===");
println!("Module: {:#?}", module);
// モジュール内の各要素を解析
for item in module.body {
match item {
swc_ecma_ast::ModuleItem::Stmt(stmt) => {
println!("Statement: {:#?}", stmt);
}
swc_ecma_ast::ModuleItem::ModuleDecl(decl) => {
println!("Module Declaration: {:#?}", decl);
}
}
}
}
Err(err) => {
println!("Parse error: {:?}", err);
}
}
let output_source_code = input_source_code.replace("foo", "bar");
handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);
Ok(())
}
pluginをビルドして実行してみると、以下のようにAST情報がコンソールに出力されます。
% bun run build.js
Build started!
=== Parsed Module Structure ===
Module: Module {
span: 13..104,
body: [
Stmt(
Decl(
Var(
VarDecl {
span: 13..31,
ctxt: #0,
kind: "const",
declare: false,
decls: [
・・・
callee: Expr(
Ident(
Ident {
span: 90..101,
ctxt: #0,
sym: "my_function",
optional: false,
},
),
),
args: [],
type_args: None,
},
),
},
)
Build completed!
これを元に内容を変更すれば、関数の前後に自動で処理を入れたり
自動でコード生成したりなど、いろいろな使い方ができそうです。